Skip to content

Python: Preserve AG-UI tool message IDs across snapshots - #7510

Draft
King Star (jstar0) wants to merge 2 commits into
microsoft:mainfrom
jstar0:fix/ag-ui-tool-message-ids
Draft

Python: Preserve AG-UI tool message IDs across snapshots#7510
King Star (jstar0) wants to merge 2 commits into
microsoft:mainfrom
jstar0:fix/ag-ui-tool-message-ids

Conversation

@jstar0

Copy link
Copy Markdown
Contributor

Motivation & Context

When an AG-UI run streams assistant text followed by a tool call, the stream uses the open text message ID as the tool call's parent. The final MESSAGES_SNAPSHOT represents text and tool calls as separate messages, but currently assigns the tool-call message a new ID. The reference AG-UI client merges snapshots by ID, so the tool call and its result are appended after later assistant text instead of remaining in their original position.

This affects any AG-UI frontend that follows the reference ID-based merge behavior and is reproducible with interleaved assistant text and tool calls.

Description & Review Guide

  • What are the major changes?
    • Allocate a message ID when a streamed tool-call segment starts.
    • Reuse that ID for ToolCallStartEvent.parent_message_id and the corresponding snapshot assistant message.
    • Add a regression test covering text, tool call, tool result, and trailing text.
  • What is the impact of these changes?
    • Streamed tool calls and final snapshots now describe the same message identity, preserving client-side ordering.
    • Tool-only turns and existing legacy snapshot fallbacks retain their current behavior.
  • What do you want reviewers to focus on?
    • Confirm that the segment ID allocation matches the AG-UI event/snapshot identity contract and does not merge tool calls into text messages.

Related Issue

Fixes #7491

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above).
  • This is not a breaking change. If it is a breaking change, add the breaking change label (or add "[BREAKING]" to the title prefix) — a workflow keeps the label and title prefix in sync automatically.

Copilot AI lite review requested due to automatic review settings August 4, 2026 15:56
@agent-framework-automation agent-framework-automation Bot added the python Usage: [Issues, PRs], Target: Python label Aug 4, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Not ready to approve

Some ToolCallStartEvent emitters in _run_common.py still parent tool calls to flow.message_id while snapshots use the newly allocated segment id, which can reintroduce the stream/snapshot ID mismatch for those tool-call paths.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Pull request overview

This PR updates the Python AG-UI streaming/snapshot implementation so tool-call “assistant messages” can keep a stable identity between streamed events and the final MESSAGES_SNAPSHOT, preserving client-side ordering for interleaved text/tool-call turns.

Changes:

  • Allocate and track a dedicated message ID for streamed tool-call segments and use it as ToolCallStartEvent.parent_message_id.
  • Reuse the tracked tool-call segment ID when building the corresponding snapshot assistant tool_calls message.
  • Add a regression test asserting that the snapshot tool-call message ID matches the streamed tool-call parent message ID after leading text.
File summaries
File Description
python/packages/ag-ui/tests/ag_ui/test_run.py Adds regression coverage ensuring snapshot tool-call message IDs reuse the streamed tool-call parent ID.
python/packages/ag-ui/agent_framework_ag_ui/_run_common.py Tracks per-tool-call-segment message IDs during streaming and uses them for ToolCallStartEvent.parent_message_id.
python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py Prefers the tracked tool-call segment ID when emitting snapshot tool_calls messages.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Lite

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Comment on lines +522 to +526
def _track_tool_call_segment(flow: FlowState, tool_call_id: str) -> str:
"""Record a tool call and return the message ID used by its stream events."""
segment: dict[str, Any]
if flow.snapshot_segments and flow.snapshot_segments[-1]["kind"] == "tool_calls":
flow.snapshot_segments[-1]["call_ids"].append(tool_call_id)
segment = flow.snapshot_segments[-1]
"""Record a tool call in the current tool segment, opening one if needed."""
def _new_tool_call_segment_id(flow: FlowState) -> str:
"""Allocate an ID that is distinct from any streamed text segment."""
text_message_ids = {segment.get("id") for segment in flow.snapshot_segments if segment["kind"] == "text"}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we make flow.message_id single-use once a tool-call segment claims it? With the supported tool-only preopen followed by text, or reasoning between two calls before a result, _new_tool_call_segment_id returns the same ID for distinct assistant snapshot messages. The reference client's ID-keyed MESSAGES_SNAPSHOT merge then overwrites either the text or an earlier tool call, so could we reserve the ID or allocate a fresh ID per new segment and assert snapshot-wide uniqueness?

return generate_event_id()


def _track_tool_call_segment(flow: FlowState, tool_call_id: str) -> str:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the predictive confirm_changes emitter use this returned ID as well? _agent_run.py:2619 still discards it while _agent_run.py:2597 emits ToolCallStartEvent.parent_message_id from flow.message_id, so that approval call streams under the text message but snapshots under the tool segment. Could we mirror _emit_approval_request by tracking before emission and passing the returned ID?

if not calls:
continue
message_id = tool_open_id or generate_event_id()
message_id = segment.get("id") or tool_open_id or generate_event_id()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we keep tool-message identity allocation local to one Module? _new_tool_call_segment_id now eagerly owns the rule in _run_common.py, but _append_segmented_snapshot_messages retains the final-state tool_open_id policy here, leaving two Modules to coordinate the same ordering invariant through the snapshot_segments Seam. That lost locality is what lets later text or reasoning invalidate the eager choice. I think one consume-once segment-ID seam makes the interface safer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

3 participants